// Copyright (c) 2013, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

library test_helper;

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:dds/dds.dart';
import 'package:observatory/service_io.dart';
import 'package:path/path.dart' as path;
import 'package:test/test.dart';
import 'service_test_common.dart';
export 'service_test_common.dart' show DDSTest, IsolateTest, VMTest;

/// Determines whether DDS is enabled for this test run.
const bool useDds = const bool.fromEnvironment('USE_DDS');

/// The directory to output VM service response size data files to.
const String serviceResponseSizesDir =
    const String.fromEnvironment('SERVICE_RESPONSE_SIZES_DIR');

/// The extra arguments to use
const List<String> extraDebuggingArgs = [];

/// Will be set to the http address of the VM's service protocol before
/// any tests are invoked.
late String serviceHttpAddress;
late String serviceWebsocketAddress;

const String _TESTEE_ENV_KEY = 'SERVICE_TEST_TESTEE';
const Map<String, String> _TESTEE_SPAWN_ENV = const {_TESTEE_ENV_KEY: 'true'};
bool _isTestee() {
  return Platform.environment.containsKey(_TESTEE_ENV_KEY);
}

const String _SKY_SHELL_ENV_KEY = 'SERVICE_TEST_SKY_SHELL';
bool _shouldLaunchSkyShell() {
  return Platform.environment.containsKey(_SKY_SHELL_ENV_KEY);
}

String? _skyShellPath() {
  return Platform.environment[_SKY_SHELL_ENV_KEY];
}

class _ServiceTesteeRunner {
  Future run(
      {Function? testeeBefore = null,
      Function? testeeConcurrent = null,
      bool pause_on_start = false,
      bool pause_on_exit = false}) async {
    if (!pause_on_start) {
      if (testeeBefore != null) {
        final result = testeeBefore();
        if (result is Future) {
          await result;
        }
      }
      print(''); // Print blank line to signal that testeeBefore has run.
    }
    if (testeeConcurrent != null) {
      final result = testeeConcurrent();
      if (result is Future) {
        await result;
      }
    }
    if (!pause_on_exit) {
      // Wait around for the process to be killed.
      stdin.first.then((_) => exit(0));
    }
  }

  void runSync(
      {Function? testeeBeforeSync = null,
      Function? testeeConcurrentSync = null,
      bool pause_on_start = false,
      bool pause_on_exit = false}) {
    if (!pause_on_start) {
      if (testeeBeforeSync != null) {
        testeeBeforeSync();
      }
      print(''); // Print blank line to signal that testeeBefore has run.
    }
    if (testeeConcurrentSync != null) {
      testeeConcurrentSync();
    }
    if (!pause_on_exit) {
      // Wait around for the process to be killed.
      stdin.first.then((_) => exit(0));
    }
  }
}

class _ServiceTesteeLauncher {
  Process? process;
  final List<String> args;
  Future<void> get exited => _processCompleter.future;
  final _processCompleter = Completer<void>();
  bool killedByTester = false;

  _ServiceTesteeLauncher({String? script})
      : args = [script ?? Platform.script.toFilePath()] {}

  // Spawn the testee process.
  Future<Process> _spawnProcess(
      bool pause_on_start,
      bool pause_on_exit,
      bool pause_on_unhandled_exceptions,
      bool enable_service_port_fallback,
      bool testeeControlsServer,
      Uri serviceInfoUri,
      int port,
      List<String>? extraArgs,
      List<String>? executableArgs) {
    if (_shouldLaunchSkyShell()) {
      return _spawnSkyProcess(
          pause_on_start,
          pause_on_exit,
          pause_on_unhandled_exceptions,
          testeeControlsServer,
          extraArgs,
          executableArgs);
    } else {
      return _spawnDartProcess(
          pause_on_start,
          pause_on_exit,
          pause_on_unhandled_exceptions,
          enable_service_port_fallback,
          testeeControlsServer,
          serviceInfoUri,
          port,
          extraArgs,
          executableArgs);
    }
  }

  Future<Process> _spawnDartProcess(
      bool pause_on_start,
      bool pause_on_exit,
      bool pause_on_unhandled_exceptions,
      bool enable_service_port_fallback,
      bool testeeControlsServer,
      Uri serviceInfoUri,
      int port,
      List<String>? extraArgs,
      List<String>? executableArgs) {
    assert(!_shouldLaunchSkyShell());

    final String dartExecutable = Platform.executable;

    final fullArgs = <String>[
      '--disable-dart-dev',
      '--no-dds',
    ];
    if (pause_on_start) {
      fullArgs.add('--pause-isolates-on-start');
    }
    if (pause_on_exit) {
      fullArgs.add('--pause-isolates-on-exit');
    }
    if (enable_service_port_fallback) {
      fullArgs.add('--enable_service_port_fallback');
    }
    fullArgs.add('--write-service-info=$serviceInfoUri');

    if (pause_on_unhandled_exceptions) {
      fullArgs.add('--pause-isolates-on-unhandled-exceptions');
    }
    fullArgs.add('--profiler');
    if (extraArgs != null) {
      fullArgs.addAll(extraArgs);
    }
    if (executableArgs != null) {
      fullArgs.addAll(executableArgs);
    }
    if (!testeeControlsServer) {
      fullArgs.add('--enable-vm-service:$port');
    }
    if (serviceResponseSizesDir.isNotEmpty) {
      // Dump service response size details to a CSV. This feature is not used
      // on the build bots and the generated output will persist after the test
      // has completed.
      final dir = path.prettyUri(serviceResponseSizesDir);
      final testName = path.withoutExtension(path.basename(args.last));
      final logName = '${testName}_${useDds ? "" : "no_"}dds_sizes.csv';
      fullArgs.add('--log_service_response_sizes=$dir/$logName');
    }
    fullArgs.addAll(args);

    return _spawnCommon(dartExecutable, fullArgs, <String, String>{});
  }

  Future<Process> _spawnSkyProcess(
      bool pause_on_start,
      bool pause_on_exit,
      bool pause_on_unhandled_exceptions,
      bool testeeControlsServer,
      List<String>? extraArgs,
      List<String>? executableArgs) {
    assert(_shouldLaunchSkyShell());

    final String dartExecutable = _skyShellPath()!;

    final dartFlags = <String>[];
    final fullArgs = <String>[];
    if (pause_on_start) {
      dartFlags.add('--pause_isolates_on_start');
      fullArgs.add('--start-paused');
    }
    if (pause_on_exit) {
      dartFlags.add('--pause_isolates_on_exit');
    }
    if (pause_on_unhandled_exceptions) {
      dartFlags.add('--pause_isolates_on_unhandled_exceptions');
    }
    dartFlags.add('--profiler');
    // Override mirrors.
    dartFlags.add('--enable_mirrors=true');
    if (extraArgs != null) {
      fullArgs.addAll(extraArgs);
    }
    if (executableArgs != null) {
      fullArgs.addAll(executableArgs);
    }
    if (!testeeControlsServer) {
      fullArgs.add('--observatory-port=0');
    }
    fullArgs.add('--dart-flags=${dartFlags.join(' ')}');
    fullArgs.addAll(args);

    return _spawnCommon(dartExecutable, fullArgs, <String, String>{});
  }

  Future<Process> _spawnCommon(String executable, List<String> arguments,
      Map<String, String> dartEnvironment) {
    final environment = _TESTEE_SPAWN_ENV;
    final bashEnvironment = new StringBuffer();
    environment.forEach((k, v) => bashEnvironment.write("$k=$v "));
    dartEnvironment.forEach((k, v) {
      arguments.insert(0, '-D$k=$v');
    });
    print('** Launching $bashEnvironment$executable ${arguments.join(' ')}');
    return Process.start(executable, arguments, environment: environment);
  }

  Future<Uri> launch(
      bool pause_on_start,
      bool pause_on_exit,
      bool pause_on_unhandled_exceptions,
      bool enable_service_port_fallback,
      bool testeeControlsServer,
      int port,
      List<String>? extraArgs,
      List<String>? executableArgs) async {
    final completer = new Completer<Uri>();
    final serviceInfoDir =
        await Directory.systemTemp.createTemp('dart_service');
    final serviceInfoUri = serviceInfoDir.uri.resolve('service_info.json');
    final serviceInfoFile = await File.fromUri(serviceInfoUri).create();
    _spawnProcess(
            pause_on_start,
            pause_on_exit,
            pause_on_unhandled_exceptions,
            enable_service_port_fallback,
            testeeControlsServer,
            serviceInfoUri,
            port,
            extraArgs,
            executableArgs)
        .then((p) async {
      process = p;
      Uri uri;
      final blankCompleter = Completer();
      bool blankLineReceived = false;
      p.stdout
          .transform(utf8.decoder)
          .transform(new LineSplitter())
          .listen((line) {
        if (!blankLineReceived && (pause_on_start || line == '')) {
          // Received blank line.
          blankLineReceived = true;
          blankCompleter.complete();
        }
        print('>testee>out> $line');
      });
      p.stderr
          .transform(utf8.decoder)
          .transform(new LineSplitter())
          .listen((line) {
        print('>testee>err> ${line.trim()}');
      });
      p.exitCode.then((exitCode) async {
        await serviceInfoDir.delete(recursive: true);
        if ((exitCode != 0) && !killedByTester) {
          throw "Testee exited with $exitCode";
        }
        print("** Process exited");
        _processCompleter.complete();
      });

      // Wait for the blank line which signals that we're ready to run.
      await blankCompleter.future;
      while ((await serviceInfoFile.length()) <= 5) {
        await Future.delayed(Duration(milliseconds: 50));
      }
      final content = await serviceInfoFile.readAsString();
      final infoJson = json.decode(content);
      String rawUri = infoJson['uri'];

      // If rawUri ends with a /, Uri.parse will include an empty string as the
      // last path segment. Make sure it's not there to ensure we have a
      // consistent Uri.
      if (rawUri.endsWith('/')) {
        rawUri = rawUri.substring(0, rawUri.length - 1);
      }
      uri = Uri.parse(rawUri);
      completer.complete(uri);
    });
    return completer.future;
  }

  void requestExit() {
    if (process != null) {
      print('** Killing script');
      if (process!.kill()) {
        killedByTester = true;
      }
    }
  }
}

void setupAddresses(Uri serverAddress) {
  serviceWebsocketAddress =
      'ws://${serverAddress.authority}${serverAddress.path}/ws';
  serviceHttpAddress = 'http://${serverAddress.authority}${serverAddress.path}';
}

class _ServiceTesterRunner {
  void run({
    List<String>? mainArgs,
    List<String>? extraArgs,
    List<String>? executableArgs,
    List<DDSTest>? ddsTests,
    List<IsolateTest>? isolateTests,
    List<VMTest>? vmTests,
    bool pause_on_start = false,
    bool pause_on_exit = false,
    bool verbose_vm = false,
    bool pause_on_unhandled_exceptions = false,
    bool enable_service_port_fallback = false,
    bool testeeControlsServer = false,
    bool enableDds = true,
    bool enableService = true,
    bool compileToKernelFirst = false,
    int port = 0,
  }) {
    if (executableArgs == null) {
      executableArgs = Platform.executableArguments;
    }
    late DartDevelopmentService dds;
    late WebSocketVM vm;
    late _ServiceTesteeLauncher process;
    bool testsDone = false;
    final tempDir = Directory.systemTemp.createTempSync('testee_dill');

    ignoreLateException(Function f) async {
      try {
        await f();
      } catch (error, stackTrace) {
        if (testsDone) {
          print('Ignoring late exception during process exit:\n'
              '$error\n$stackTrace');
        } else {
          rethrow;
        }
      }
    }

    setUp(
      () => ignoreLateException(
        () async {
          String testeePath = Platform.script.toFilePath();
          if (compileToKernelFirst && testeePath.endsWith('.dart')) {
            final testeePathDill = path.join(tempDir.path, 'testee.dill');
            final ProcessResult result =
                await Process.run(Platform.executable, [
              '--snapshot-kind=kernel',
              '--snapshot=$testeePathDill',
              ...Platform.executableArguments,
              testeePath,
            ]);
            if (result.exitCode != 0) {
              throw 'Failed to compile testee to kernel:\n'
                  'stdout: ${result.stdout}\n'
                  'stderr: ${result.stderr}\n'
                  'exitCode: ${result.exitCode}\n';
            }
            testeePath = testeePathDill;
          }

          process = _ServiceTesteeLauncher(script: testeePath);
          await process
              .launch(
                  pause_on_start,
                  pause_on_exit,
                  pause_on_unhandled_exceptions,
                  enable_service_port_fallback,
                  testeeControlsServer,
                  port,
                  extraArgs,
                  executableArgs)
              .then((Uri serverAddress) async {
            if (mainArgs != null && mainArgs.contains("--gdb")) {
              final pid = process.process!.pid;
              final wait = new Duration(seconds: 10);
              print("Testee has pid $pid, waiting $wait before continuing");
              sleep(wait);
            }
            if (useDds) {
              dds = await DartDevelopmentService.startDartDevelopmentService(
                  serverAddress);
              setupAddresses(dds.uri!);
            } else {
              setupAddresses(serverAddress);
            }
            print('** Signaled to run test queries on $serviceHttpAddress'
                ' (${useDds ? "DDS" : "VM Service"})');
            vm =
                new WebSocketVM(new WebSocketVMTarget(serviceWebsocketAddress));
            print('Loading VM...');
            await vm.load();
            print('Done loading VM');
          });
        },
      ),
    );

    tearDown(
      () => ignoreLateException(
        () async {
          if (useDds) {
            await dds.shutdown();
          }
          process.requestExit();
        },
      ),
    );

    final name = Platform.script.pathSegments.last;
    runTest(String name) {
      test(
        '$name (${useDds ? 'DDS' : 'VM Service'})',
        () => ignoreLateException(
          () async {
            // Run vm tests.
            if (vmTests != null) {
              int testIndex = 1;
              final totalTests = vmTests.length;
              for (var test in vmTests) {
                vm.verbose = verbose_vm;
                print('Running $name [$testIndex/$totalTests]');
                testIndex++;
                await test(vm);
              }
            }

            // Run dds tests.
            if (ddsTests != null) {
              int testIndex = 1;
              final totalTests = ddsTests.length;
              for (var test in ddsTests) {
                vm.verbose = verbose_vm;
                print('Running $name [$testIndex/$totalTests]');
                testIndex++;
                await test(vm, dds);
              }
            }

            // Run isolate tests.
            if (isolateTests != null) {
              final isolate = await getFirstIsolate(vm);
              int testIndex = 1;
              final totalTests = isolateTests.length;
              for (var test in isolateTests) {
                vm.verbose = verbose_vm;
                print('Running $name [$testIndex/$totalTests]');
                testIndex++;
                await test(isolate);
              }
            }

            print('All service tests completed successfully.');
            testsDone = true;
          },
        ),
        // Some service tests run fairly long (e.g., valid_source_locations_test).
        timeout: Timeout.none,
      );
    }

    if ((useDds && !enableDds) || (!useDds && ddsTests != null)) {
      print('Skipping DDS run for $name');
      return;
    }
    if (!useDds && !enableService) {
      print('Skipping VM Service run for $name');
      return;
    }
    runTest(name);
  }

  Future<Isolate> getFirstIsolate(WebSocketVM vm) async {
    if (vm.isolates.isNotEmpty) {
      final isolate = await vm.isolates.first.load();
      if (isolate is Isolate) {
        return isolate;
      }
    }

    Completer<Isolate>? completer = new Completer<Isolate>();
    vm.getEventStream(VM.kIsolateStream).then((stream) {
      var subscription;
      subscription = stream.listen((ServiceEvent event) async {
        if (completer == null) {
          subscription.cancel();
          return;
        }
        if (event.kind == ServiceEvent.kIsolateRunnable) {
          if (vm.isolates.isNotEmpty) {
            vm.isolates.first.load().then((result) {
              if (result is Isolate) {
                subscription.cancel();
                completer!.complete(result);
                completer = null;
              }
            });
          }
        }
      });
    });

    // The isolate may have started before we subscribed.
    if (vm.isolates.isNotEmpty) {
      vm.isolates.first.reload().then((result) async {
        if (completer != null && result is Isolate) {
          completer!.complete(result);
          completer = null;
        }
      });
    }
    return await completer!.future;
  }
}

/// Runs [tests] in sequence, each of which should take an [Isolate] and
/// return a [Future]. Code for setting up state can run before and/or
/// concurrently with the tests. Uses [mainArgs] to determine whether
/// to run tests or testee in this invocation of the script.
Future runIsolateTests(List<String> mainArgs, List<IsolateTest> tests,
    {Function? testeeBefore,
    Function? testeeConcurrent,
    bool pause_on_start = false,
    bool pause_on_exit = false,
    bool verbose_vm = false,
    bool pause_on_unhandled_exceptions = false,
    bool testeeControlsServer = false,
    bool enableDds = true,
    bool enableService = true,
    List<String>? extraArgs}) async {
  assert(!pause_on_start || testeeBefore == null);
  if (_isTestee()) {
    new _ServiceTesteeRunner().run(
        testeeBefore: testeeBefore,
        testeeConcurrent: testeeConcurrent,
        pause_on_start: pause_on_start,
        pause_on_exit: pause_on_exit);
  } else {
    new _ServiceTesterRunner().run(
      mainArgs: mainArgs,
      extraArgs: extraArgs,
      isolateTests: tests,
      pause_on_start: pause_on_start,
      pause_on_exit: pause_on_exit,
      verbose_vm: verbose_vm,
      pause_on_unhandled_exceptions: pause_on_unhandled_exceptions,
      testeeControlsServer: testeeControlsServer,
      enableDds: enableDds,
      enableService: enableService,
    );
  }
}

/// Runs [tests] in sequence, each of which should take an [Isolate] and
/// return a [Future]. Code for setting up state can run before and/or
/// concurrently with the tests. Uses [mainArgs] to determine whether
/// to run tests or testee in this invocation of the script.
///
/// This is a special version of this test harness specifically for the
/// pause_on_unhandled_exceptions_test, which cannot properly function
/// in an async context (because exceptions are *always* handled in async
/// functions).
void runIsolateTestsSynchronous(List<String> mainArgs, List<IsolateTest> tests,
    {Function? testeeBefore,
    Function? testeeConcurrent,
    bool pause_on_start = false,
    bool pause_on_exit = false,
    bool verbose_vm = false,
    bool pause_on_unhandled_exceptions = false,
    List<String>? extraArgs}) {
  assert(!pause_on_start || testeeBefore == null);
  if (_isTestee()) {
    new _ServiceTesteeRunner().runSync(
        testeeBeforeSync: testeeBefore,
        testeeConcurrentSync: testeeConcurrent,
        pause_on_start: pause_on_start,
        pause_on_exit: pause_on_exit);
  } else {
    new _ServiceTesterRunner().run(
        mainArgs: mainArgs,
        extraArgs: extraArgs,
        isolateTests: tests,
        pause_on_start: pause_on_start,
        pause_on_exit: pause_on_exit,
        verbose_vm: verbose_vm,
        pause_on_unhandled_exceptions: pause_on_unhandled_exceptions);
  }
}

/// Runs [tests] in sequence, each of which should take a [VM] and
/// return a [Future]. Code for setting up state can run before and/or
/// concurrently with the tests. Uses [mainArgs] to determine whether
/// to run tests or testee in this invocation of the script.
Future runVMTests(List<String> mainArgs, List<VMTest> tests,
    {Function? testeeBefore,
    Function? testeeConcurrent,
    bool pause_on_start = false,
    bool pause_on_exit = false,
    bool verbose_vm = false,
    bool pause_on_unhandled_exceptions = false,
    bool enable_service_port_fallback = false,
    bool enableDds = true,
    bool enableService = true,
    bool compileToKernelFirst = false,
    int port = 0,
    List<String>? extraArgs,
    List<String>? executableArgs}) async {
  if (_isTestee()) {
    new _ServiceTesteeRunner().run(
        testeeBefore: testeeBefore,
        testeeConcurrent: testeeConcurrent,
        pause_on_start: pause_on_start,
        pause_on_exit: pause_on_exit);
  } else {
    new _ServiceTesterRunner().run(
      mainArgs: mainArgs,
      extraArgs: extraArgs,
      executableArgs: executableArgs,
      vmTests: tests,
      pause_on_start: pause_on_start,
      pause_on_exit: pause_on_exit,
      verbose_vm: verbose_vm,
      pause_on_unhandled_exceptions: pause_on_unhandled_exceptions,
      enable_service_port_fallback: enable_service_port_fallback,
      enableDds: enableDds,
      enableService: enableService,
      compileToKernelFirst: compileToKernelFirst,
      port: port,
    );
  }
}

/// Runs [tests] in sequence, each of which should take a [VM] and
/// [DartDevelopmentService] and return a [Future]. Code for setting up state
/// can run before and/or concurrently with the tests. Uses [mainArgs] to
/// determine whether to run tests or testee in this invocation of the
/// script.
Future runDDSTests(List<String> mainArgs, List<DDSTest> tests,
    {Function? testeeBefore,
    Function? testeeConcurrent,
    bool pause_on_start = false,
    bool pause_on_exit = false,
    bool verbose_vm = false,
    bool pause_on_unhandled_exceptions = false,
    bool enable_service_port_fallback = false,
    int port = 0,
    List<String>? extraArgs,
    List<String>? executableArgs}) async {
  if (_isTestee()) {
    new _ServiceTesteeRunner().run(
        testeeBefore: testeeBefore,
        testeeConcurrent: testeeConcurrent,
        pause_on_start: pause_on_start,
        pause_on_exit: pause_on_exit);
  } else {
    new _ServiceTesterRunner().run(
      mainArgs: mainArgs,
      extraArgs: extraArgs,
      executableArgs: executableArgs,
      ddsTests: tests,
      pause_on_start: pause_on_start,
      pause_on_exit: pause_on_exit,
      verbose_vm: verbose_vm,
      pause_on_unhandled_exceptions: pause_on_unhandled_exceptions,
      enable_service_port_fallback: enable_service_port_fallback,
      enableDds: true,
      enableService: false,
      port: port,
    );
  }
}
